Skip to content

ddk: verify a signed contract from its three messages - #231

Merged
bennyhodl merged 4 commits into
masterfrom
benny/225-verify-signed-contract
Oct 29, 2026
Merged

bennyhodl merged 4 commits into
masterfrom
benny/225-verify-signed-contract

Conversation

@bennyhodl

@bennyhodl bennyhodl commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Item 2 of #225. apply_funding_signatures copied the offering party's funding witnesses without checking them, and finalize_sign trusted them. advanced::verify_cet_adaptor_signatures rebuilds under the default fee rule, so a contract signed under OwnPayoutOnly couldn't be verified through the public API. Lygos wrote its own verify_sign to cover all of this.

API

  • ddk::contract::verify_signed_contract(offer, accept, sign) -> VerifiedContract picks the fee rule from the sign message's contract id, the same way create_signed_dlc_transactions does. It then checks:
    • both parties' refund signatures and CET adaptor signatures;
    • each ordinary offer funding witness against the output it spends: P2WPKH, P2SH-P2WPKH or Taproot key path, with SIGHASH_ALL and the correct script_sig;
    • each offer splice half against the previous contract's offer key. A half is [half] or [half, key]; anything else is rejected.
  • A success means everything the three messages carry was verified. An offer funding witness DDK can't check fails, and so does one longer than its input's declared max_witness_len:
    • an input of any other script type, such as P2WSH multisig from an external signer, fails with ContractError::UnverifiableFundingWitness;
    • a witness over max_witness_len fails with ContractError::FundingWitnessTooLong. The funding fee was paid on the declared length, so a longer witness pays less than the agreed rate.
  • VerifiedContract exposes contract_id(), transactions() and into_transactions().
    • The accepting party's funding witnesses and splice halves never travel in the messages, so they can't be checked here.
  • ddk-dlc:
    • verify_funding_witness(secp, fund_tx, index, &TxInputInfo, prevouts, &Witness), plus new Error::UnsupportedScriptType and Error::WitnessTooLong.
    • A 65-byte Taproot signature with hash type 0x00 is rejected. BIP-341 allows the default type only in the 64-byte form; bitcoin's Signature::from_slice accepts it, and such a witness can't confirm.
    • util::multisig_witness(script, (pk, sig), (pk, sig)). sign_multi_sig_input and combine_dlc_input_signatures both use it, so there is one 2-of-2 witness builder.

Lifecycle

  • finalize_sign, finalize_sign_spliced and the advanced::finalize_sign_*_with_funding_signatures variants run verify_signed_contract first, so they now refuse an offer funded from a script type DDK can't check, and an offer witness over its declared length. That replaces the separate offer-half check in complete_dlc_input_witnesses, so there is one verification path. Finalize also picks the fee rule from the sign message now, instead of always using the current rule.
  • The signature checks in context.rs are split into verify_refund_signature and verify_adaptor_signatures. sign.rs, advanced.rs and settle.rs use them instead of their own copies.
  • advanced::verify_cet_adaptor_signatures runs before a sign message exists, so it has no contract id to pick a fee rule. It is documented as such and points to verify_signed_contract.

Breaking changes

  • Behaviour: finalize_sign and its variants refuse a contract whose offer is funded from a P2WSH multisig (or any script other than P2WPKH, P2SH-P2WPKH or Taproot key path). An external signer that funded offers that way finalized before this PR and doesn't now.
  • Behaviour: an offer funding witness longer than its input's max_witness_len is refused. A P2WPKH witness with a low-S signature is at most 108 bytes (1 + 1 + 72 + 1 + 33), so the standard declared 108 holds without low-R grinding.
  • ddk: ContractError gains UnverifiableFundingWitness and FundingWitnessTooLong.
  • ddk-dlc: combine_dlc_input_signatures takes &[u8] and returns Result<Witness, Error>. Add ? at call sites.
  • ddk-dlc: verify_dlc_funding_input_signature requires DER plus SIGHASH_ALL; it used to accept a compact signature. Callers holding a raw signature pass util::finalize_sig(&sig, EcdsaSighashType::All).
  • ddk-dlc: Error gains UnsupportedScriptType and WitnessTooLong.

Not changed / follow-ups

  • Settlement and splice construction still rebuild with signed_context rather than consuming VerifiedContract. A refund should only need the counterparty's refund signature, and the keyless-settlement PR (ddk: settle a contract from its messages without a key #228) restructures settle.rs. Settlement entry points on VerifiedContract should follow once both land.
  • A manager splice offered by the previous contract's accepting party signs with remote_fund_pubkey. The stateless API checks local_fund_pubkey, as master already did.
  • ddk-ffi (ffi/src/lib.rs:470) passes pre-sorted keys to combine_dlc_input_signatures while the signatures stay in local/remote order. When local_pk > remote_pk that builds an invalid witness. This bug predates this PR; it needs a fix in ddk-ffi, along with the new ?.

Testing

  • New in ddk-dlc:
    • funding_witnesses_verify_for_each_supported_coin: all three coin types verify. Taproot SIGHASH_NONE, a P2WSH coin, and a missing, wrong or unexpected script_sig are each rejected.
    • multisig_witness_orders_signatures_by_public_key.
  • New in ddk/tests/stateless.rs:
    • a valid contract verifies, and so does the pre-rc.4 OwnPayoutOnly fixture;
    • these are rejected: a tampered offer witness, a wrong-key witness, a SIGHASH_NONE witness, a tampered refund signature (each side), a tampered adaptor signature (each side), and a non-ALL splice half;
    • a splice half may carry its key but nothing else;
    • an offer funded from a script DDK can't check is refused, and so is an offer witness longer than its declared max_witness_len;
    • finalize_sign rejects a bad funding witness.
  • Unit suites: ddk-dlc 25, ddk contract:: 29, stateless 63, ddk-manager lib 62, all passing.
  • stateless_execution --ignored on regtest: single-funded, splice in/out, alternating splice chain, accept-party splice, numeric, disjoint and the DDK wallet signer all pass.
  • cargo fmt --check, cargo clippy -- -D warnings, cargo check -p ddk --no-default-features and cargo check -p ddk-dlc --no-default-features --features no-std pass.
  • Reviewed by GPT-6 Astra. Fixed from the review:
    • script_sig was unchecked;
    • unsupported script types were rejected at finalize, which broke external funding;
    • extra trailing elements after a splice half were accepted;
    • migration notes were missing.
      The remaining points are listed above.
  • Checked against Orange Grove's DLCs:
    • all 180 signed staging contracts, made under ddk 1.x (OwnPayoutOnly) and 2.0, pass verify_signed_contract, and each rebuilds the funding txid on chain;
    • Orange Grove's test suite passes with its verify_sign replaced by verify_signed_contract.

@bennyhodl

Copy link
Copy Markdown
Owner Author

Note

🤖 Claude Opus 5.5 responding on behalf of bennyhodl

Review against orange-grove's consumers (lygos_utils::dlc, the wallet crate, the server's Fordefi signer) before 2.0.0. Change first.

This is the verifier orange-grove wants to share between the wallet's import and the server's submission check; it replaces about 200 lines of lygos-utils/src/dlc/complete.rs. Before freezing:

  1. Bug: 65-byte Taproot signature with sighash byte 0x00. bitcoin::taproot::Signature::from_slice maps a trailing 0x00 to TapSighashType::Default, and the key-path check allows Default | All. BIP-341 rejects a 65-byte signature whose hash type is 0x00; Default is valid only as a 64-byte signature. Check the length/byte before accepting Default.
  2. Name the partial guarantee. verify_signed_contract succeeds while unverified_funding_inputs lists witnesses it could not check. Either add a strict entry point that refuses them, or name the type for what it guarantees; as it stands every caller has to remember to check the list.
  3. max_witness_len. It is deliberately not checked. Orange-grove checks it because the fee was paid on the declared bound. Make it an explicit choice in the docs, or check it.
  4. State prominently that the accepting party's funding witnesses are not in the messages, so they can't be verified here.

finalize_sign copied the offering party's funding witnesses out of the
sign message without checking them, so a bad witness produced a funding
transaction that could not confirm. Consumers that import or restore a
contract had no public way to check the messages at all, and
advanced::verify_cet_adaptor_signatures rebuilds under the current fee
rule, so it rejects valid OwnPayoutOnly contracts.

ddk::contract::verify_signed_contract rebuilds the contract under the fee
rule its sign message names, then checks both parties' refund and CET
adaptor signatures, every offer funding witness against its prevout, and
every offer splice half against the previous contract's key. It returns a
VerifiedContract with the contract id, the transactions, and the offer
funding inputs whose script type cannot be checked (a P2WSH multisig, for
example). Those are neither accepted nor rejected; finalize proceeds, and
a consumer that needs every signature verified requires the list to be
empty. A splice half may be followed by its offer key, nothing else.

finalize_sign and finalize_sign_spliced run the verification before
completing the funding transaction. Settlement shares the same refund and
adaptor checks but verifies only the counterparty half it combines, so a
refund never depends on the rest of the messages.

ddk-dlc gains verify_funding_witness (P2WPKH, P2SH-P2WPKH, Taproot key
path, SIGHASH_ALL only, script signature checked; other script types
return the new Error::UnsupportedScriptType) and util::multisig_witness,
which sign_multi_sig_input and combine_dlc_input_signatures now share.
verify_dlc_funding_input_signature and combine_dlc_input_signatures
reject a half that is not DER plus SIGHASH_ALL, and combine now returns
a Result.
verify_signed_contract returned success for a contract whose offer
funding witness spent a script type DDK cannot check, and listed the
input in VerifiedContract::unverified_funding_inputs. Every caller then
had to remember to look at that list before treating the contract as
verified, and finalize_sign never did. A success now means everything
the three messages carry was verified: an unverifiable offer witness
fails with the new ContractError::UnverifiableFundingWitness, and
unverified_funding_inputs is gone. No caller needed the lenient result:
finalize_sign was its only user, and it is strict too. The accepting
party's funding witnesses are not in the messages, so they stay outside
what verify_signed_contract checks; the docs now say so up front.

verify_funding_witness also checks the witness against the input's
max_witness_len. The funding fee was paid on the declared length, so a
longer witness pays less than the agreed rate. It fails with
ddk_dlc::Error::WitnessTooLong, surfaced as
ContractError::FundingWitnessTooLong.

It also rejected too little for Taproot. bitcoin's Signature::from_slice
reads a trailing 0x00 as the default sighash type, but BIP-341 makes a
65-byte signature with hash type 0x00 invalid; the default is only the
64-byte form. Such a witness passed verification and could not confirm.
@bennyhodl
bennyhodl force-pushed the benny/225-verify-signed-contract branch from 4b72d44 to 785180c Compare October 15, 2026 18:11
RUSTSEC-2026-0385 (ctutils < 0.4.4) and RUSTSEC-2026-0414
(pulldown-cmark < 0.13.5) fail the audit job. Both are patch bumps in
the lockfile only.
@bennyhodl
bennyhodl merged commit 4d628de into master Oct 29, 2026
210 of 294 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant